0.3.x#20
Open
math3usmartins wants to merge 222 commits into
Open
Conversation
Introduce the XPHP\Diagnostics namespace: a string-backed Severity enum (Error/Warning/Notice with isFailing()), a DiagnosticSource enum (xphp/phpstan), a SourceLocation (file/line/optional column), the immutable Diagnostic, and a mutable DiagnosticCollector (add/all/hasErrors/count). Pure value objects with no pipeline wiring yet — the foundation for the forthcoming `xphp check` command's structured, collect-all diagnostics. Unit tests cover severity gating, collector ordering/error detection, and defaults (100% mutation score over the new files). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tics sink Thread an optional DiagnosticCollector through the bound-validation path (Registry ctor -> recordInstantiation -> validateBounds -> checkBounds). When absent (xphp compile) violations throw exactly as before, byte-identical; when present each violation is appended as a Diagnostic -- located at the instantiation site captured from the AST node in RegistryCollector -- and recording continues so all violations surface in one run. The user-facing message now comes from a single shared boundViolationMessage() builder so the throw text and the diagnostic text can never drift. Tests cover collect-vs-throw, byte-identical and exact message text, multi-violation collection, and AST-derived source line (100% mutation score over the diff). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Compiler::check(): parse, build the type hierarchy, collect definitions, validate defaults-against-bounds, collect instantiations (bounds + missing type arguments), and report instantiations of undefined templates -- gathering every error into a DiagnosticCollector and halting before specialization/emit, so a partially-invalid registry never reaches the fixed-point loop. Extends the optional-collector seam to the padding path (missing required type argument), validateDefaultsAgainstBounds (per-parameter, continue-on-error), and a new collectUndefinedTemplates pass. Each reused message is built by a single shared helper so the throw (compile) and diagnostic (check) text stay byte-identical. The parse loop is factored into parseAll(), reused by compile() and check(); compile()'s undefined-template throw now routes through the shared builder. Duplicate-definition is intentionally not part of the seam: RegistryCollector's already-recorded guard makes the class-template path unreachable and surfacing it would change compile-mode semantics -- deferred. Variance and method-level generic checks remain fail-fast (not yet part of the check pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase Move the variance-position check out of the parser into a Registry phase (validateVariancePositions) over collected definitions, wired into compile() (fail-fast, byte-identical first-violation throw) and check() (collects every violation across all definitions, each located at the offending member). VariancePositionValidator now accumulates violations behind a static assertPositions facade that throws the first when no collector is given or emits a diagnostic per violation when one is. The parser-level variance-position tests move to a dedicated VariancePositionPhaseTest (compile-mode throws via data provider + check-mode collect/location), and the check integration suite gains a variance_violation fixture covering the compile-throw and check-collect paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extract the inner-variance composition walk out of Registry into a dedicated InnerVarianceValidator (mirroring VariancePositionValidator): it accumulates violations behind a static assertComposition facade that throws the first when no collector is given (compile, byte-identical) or emits a diagnostic per violation (check), each located at the offending member. Registry's validateInnerVariance is now a thin delegate. To avoid double-reporting a direct +T/-T misuse, the position check now returns which definitions it flagged and the inner-variance pass skips them -- matching compile-mode, where the position check fails fast before inner-variance runs. Both passes are wired into Compiler::check(); compile() is unchanged. Adds inner-variance check fixture + collect-mode, gating, and null-file tests (100% mutation score over the new validator and the diff). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the variance-position check before the defaults-vs-bounds check so a class with both surfaces the variance error first in compile-mode — the order it surfaced when the check lived in the parser. Merge the stacked docblocks on the two variance delegate methods. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a DiagnosticRenderer interface and three implementations for `xphp check` output: TextRenderer (human-readable blocks), JsonRenderer (a stable documented JSON contract), and GithubRenderer (Actions workflow-command annotations with proper escaping). Unit tests pin each format exactly, including the JSON shape and GitHub escaping (100% mutation score over the renderers). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire a CheckCommand (`xphp check <source> [--format=text|json|github]`) into the console alongside compile, sharing one Compiler. It runs the validate-only pass, renders diagnostics in the chosen format, and exits 0 (clean) / 1 (errors) / 2 (bad source dir or unknown format). Compiler::check() now parses each file in its own try/catch: a file that fails to parse (PHP syntax error or an xphp-specific parse rejection) is reported as a diagnostic and skipped, so the remaining files are still checked. Tests drive the command via CommandTester across all formats/exit codes, and a parse_error fixture proves a valid file's bound violation is still reported alongside two unparseable files. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the file read out of check()'s per-file try so an I/O failure surfaces as itself rather than being mislabeled xphp.parse_error; only parsing is treated as a recoverable per-file diagnostic. Clarify the parse-error line comment re nikic's -1 sentinel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an `xphp check` section to the errors reference (formats, exit codes, per-file parse resilience, and the stable diagnostic codes for the json/github formats) and a short pointer from the README quick start. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pile` Spell out the scope consequence: a clean `check` does not guarantee a clean `compile`, because method/function/closure-level generic checks (and the specialization-loop guards, by design) are not run by `check` yet. Advise keeping `compile` in the build pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run GenericMethodCompiler in a new validate-only mode from Compiler::check(): process(emit: false) walks the call sites for their bound/missing-arg checks and the duplicate-function / $this-capture / static-closure rejections, threading the optional DiagnosticCollector + source locations through the (already collector-aware) Registry::checkBounds/padArgsWithDefaults and the in-process throws, while suppressing the specialize/strip/finalize side-effects. xphp compile is unchanged (default emit: true, no collector -> byte-identical fail-fast). This makes `xphp check` a validation-superset of `xphp compile`: a class-level and a method-level generic error are now both reported in one run. New diagnostic codes xphp.duplicate_generic_function / xphp.closure_this_capture / xphp.static_closure; bound + missing-arg reuse the existing codes. Fixtures + CheckPassIntegrationTest cover each new collected diagnostic (with file:line), the both-passes-in-one-run guarantee, and byte-identical-compile guards. Docs updated: check now covers all generic validation; only the specialization-loop guards (depth cap, hash collision) remain compile-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a closure_static fixture + check-collect and compile-throws tests for the generic static-closure rejection (xphp.static_closure), matching the symmetry of the other method-level checks (the collect path was previously untested). Add the three new method-level codes to the errors-doc table, and correct the validate-only comments (the discarded per-file AST may carry in-place call-site rewrites; templates are deep-cloned so nothing shared is mutated). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump phpstan.neon from level 7 to level 9 and make src/ clean at it: - CompileCommand / CheckCommand: narrow getArgument()/getOption() (typed mixed) to string via is_string() instead of a blind (string) cast — the inputs are always strings (required argument / option with a string default), so behavior is unchanged; level 9 just rejects casting mixed. - Specializer: annotate the ATTR_GENERIC_ARGS array as list<TypeRef> so array_map infers the callback's parameter type (level-9 callable-variance check). Full suite green; src/ clean at level 9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The check command is unit-tested in-process via CommandTester, but nothing exercised the real binary: its autoload wiring, the process exit code the shell sees, or the rendered github/json/text output on stdout. The released PHAR was only smoke-tested with `list`, never `check`. Add test/smoke/check.sh — a parameterized POSIX script (XPHP_BIN selects the binary) that runs `check` against the clean and multi_error fixtures and asserts the 0/1/2 exit contract plus that every renderer emits and json stays well-formed. Wire it in: - Makefile: `test/check` target. - ci-core.yml: a dedicated `xphp check (self-test)` job running `make test/check` against bin/xphp. - release.yml: a post-build step running the same script against dist/xphp.phar, so a packaged binary that can't gate fails the release before upload. No src/ changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the PHPStan integration for `xphp check`. PHPStan can't see `.xphp` generic sugar, so the gate will compile to a throwaway dir and analyse the concrete output. This adds the three building blocks, each unit-tested: - PhpStanLocator: resolve the phpstan binary (explicit path → consumer vendor/bin/phpstan → $PATH); null when none found (caller emits a non-fatal Warning — a missing optional tool never fails the gate). - PhpStanConfigResolver: resolve the consumer's config (explicit → auto-detect phpstan.neon / .neon.dist / .dist.neon). This is the "one config" that drives level/rules/extensions. - CompiledWorkspace: compile sources into a temp dir (dist/ + cache/Generated/), retain the live Registry for back-mapping findings to template declarations, and recursively clean up (guarding against following symlinks out of the dir). Promote symfony/process to a runtime require: the runner ships in the PHAR and shells out to the consumer's phpstan, but it was only present transitively via a dev dependency and would be dropped by `composer install --no-dev`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e findings Second slice of the PHPStan integration. Given a compiled workspace: - RepresentativeSelector picks one specialization per template (first by sorted generated FQN — deterministic), mapping it to its file path and back to the template's declaration (file + line) via the live Registry. Body type errors are erased to nominal types during specialization, so they're identical across a template's instantiations — analysing one representative surfaces the bug once instead of N times. - PhpStanRunner writes an ephemeral neon that `includes:` the consumer's config by absolute path (so their relative bootstrapFiles/excludePaths still resolve) and adds scanDirectories for symbol resolution, then runs `php <phpstan> analyse <representatives>` via Symfony Process. Analyse paths on the CLI override the consumer's `paths`; level is inherited (or a default when there's no consumer config). - PhpStanOutputParser turns --error-format=json into findings, and crucially treats unparseable output or file-less top-level errors as a FAILED run (the caller will Warn) rather than a false clean pass. Workspace dist/Generated dirs are canonicalized (realpath) so the file paths PHPStan reports join exactly to the representatives even when the temp root is reached through a symlink (e.g. macOS /var). Adds the body_type_error fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eck` Completes the PHPStan integration: when the generic checks pass, `xphp check` now compiles to a throwaway workspace, runs the consumer's PHPStan over the representative specializations, and merges the findings into the same report and exit code — one gate. - PhpStanResultMapper anchors each finding at the originating template's .xphp declaration line, with triggeredBy naming the concrete instantiation. An unmatched finding (not expected — only representatives are analysed) is surfaced without a location rather than leaking a throwaway temp path. - StaticAnalysisGate orchestrates locate → compile → select → run → map, cleans up the workspace in finally, and turns a missing binary or a failed run into a non-failing Warning (never a false clean pass, never exit 2). Generic errors short-circuit the pass. - CheckCommand gains --no-phpstan / --phpstan-bin / --phpstan-config and runs the gate only when Phase 1 is clean. - GithubRenderer folds triggeredBy into the annotation message (annotations have no separate field), so PR output shows which instantiation surfaced a body error. e2e tests pin behaviour against a fixed level-5 config fixture (not the repo's own phpstan.neon) and skip when no phpstan binary is installed. Infection's per-mutant timeout is raised to 120s because these tests shell out to a real phpstan. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…unit Tag the three tests that shell out to a real phpstan binary with @group phpstan (they already self-skip when vendor/bin/phpstan is absent). Exclude that group from the fast default `make test/unit` and add `make test/phpstan` to run it on its own — mirroring the existing php85 group split. Verified the suite is green both with phpstan installed (the pass runs) and without it (the group self-skips); pure unit tests for the mapper, parser, config builder, locator, and workspace always run regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- docs/errors.md + README: document the PHPStan-over-compiled-output pass — one config, the binary/config resolution order, one-representative-per-template, the Warning-not-failure semantics, --no-phpstan / --phpstan-bin / --phpstan-config, and the new phpstan.* diagnostic codes. - CHANGELOG: add an Unreleased section for `xphp check` and the PHPStan pass. - CONTRIBUTING: document the `@group phpstan` self-skip convention and the target. - ci-core.yml: add a `PHPStan pass` job running the @group phpstan tests (composer install provides the binary). - Makefile: name the target `test/phpstan-pass` to disambiguate from `lint/phpstan`. - smoke: pass --no-phpstan so the binary exit/render self-test stays deterministic and independent of a phpstan install (the PHAR bundles none); the PHPStan path is covered in-process by CheckCommandPhpStanTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundations for catching a stray/undeclared type parameter in a generic member —
e.g. `interface Foo<Z> { add(T $x): void; }`, which today compiles to a reference
to a non-existent class `\App\Foo\T` with no diagnostic. Behind a no-op (no reader
yet); a later change consumes these to fail compile and report in check.
- NamespaceContext::isImported — is a bare name's first segment brought in by a
`use`? (imported names are the escape hatch and never flagged).
- TypeHierarchy::isDeclared — does an FQN name a class/interface/trait declared in
the scanned sources, or a built-in? (reuses the existing ancestor-map walk).
- XphpSourceParser: tag a bare, single-segment, non-imported class name used inside
a generic context (template or generic method/closure) with a new
ATTR_SUSPECT_UNDECLARED_TYPE attribute carrying its resolved FQN. shouldQualify()
already excludes declared params / scalars / FQ / generic-arg names, so a tagged
name is exactly "a real type or a stray type parameter" — the validator decides
which via isDeclared.
A dry-run of the rule over the entire .xphp fixture corpus flagged nothing, so it
has no false positives on existing valid code. Compile byte-identical; suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/typo'd type parameter in a generic class/interface/trait member —
e.g. `interface Foo<Z> { public function add(T $x): void; }` where `T` is not a
declared parameter. Previously `xphp compile` silently emitted a reference to a
non-existent class (`\App\Foo\T`) and `xphp check` reported nothing; now it fails
at compile and is collected by check (even without PHPStan, even when the template
is never instantiated).
UndeclaredTypeParameterValidator (mirrors VariancePositionValidator) walks every
member signature position — properties, constructor-promoted + method params,
returns, union/intersection/nullable, and nested closure/arrow signatures — and
flags a name the parser tagged as suspect (bare, single-segment, non-imported,
inside a generic context) whose resolved FQN names no declared or built-in type.
Wired into Compiler::compile (throw, fail-fast) and ::check (collect-all) before
the defaults check. Code `xphp.undeclared_type`.
Imported (`use`) and fully-qualified names are the escape hatch and are never
flagged; the accepted limitation (a same-namespace class in an unscanned plain
`.php` file) is documented with the remedy. Generic methods declared outside a
generic template are validated separately (follow-up); nested ones are covered here.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Catches a stray/undeclared type parameter in generic methods, free functions,
closures, and arrows declared OUTSIDE a generic template — e.g. a generic method
on a plain class, or `function wrap<A>(C $x)` where `C` is a stray. Like the
class-level check, it fails compile (fail-fast, before specialization strips the
templates) and is collected by check.
UndeclaredTypeParameterValidator gains assertMethodLevel(): it walks the AST for
generic method/function/closure/arrow signatures NOT enclosed by a generic
template (which the member walk already owns — a depth counter avoids reporting
the same node twice) and validates them via the shared checkCallable(). The
diagnostic message now names the context ("method `pick`", "function `wrap`",
"closure", "arrow function", or "template `Foo`").
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the undeclared-type check to a type parameter's bound and default — e.g. `class Box<T: Nonexistent>` or `class Pair<A, B = Nonexistent>`, where the name is a stray/typo'd reference that previously resolved silently to a non-existent class. Bounds and defaults are TypeRef trees (not AST type nodes, so they carry no attribute), so TypeRef gains a `suspectUndeclared` flag the parser sets under the same rule as the member-hint tag (bare, single-segment, non-imported, inside a generic context, not a declared param). The validator walks each parameter's bound (incl. intersection/union operands and generic-arg leaves) and default, flagging suspect names that resolve to no declared/built-in type; duplicates of one name (e.g. `<T: Bad = Bad>`) collapse to a single finding. Fails compile and is collected by check, reusing `xphp.undeclared_type`. Built-in, imported, fully-qualified, multi-segment, and param-referencing bounds/defaults are not flagged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ating `Box::<int, string>` for a one-parameter `Box` used to silently drop the extra argument and proceed as `Box<int>`. Registry::padArgsWithDefaults now splits its fast-return: an over-supplied tuple (`> needed`) reports xphp.too_many_type_arguments (via the already-threaded collector/source-location, else throws), while an exact-arity tuple keeps the fast-return and under-arity still pads / reports a missing argument. Covers class- and method/function-level generics (both route through padArgsWithDefaults). Returning the over-long tuple lets the downstream arity guards skip specialization, so no broken code is emitted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A scalar bound like `class Box<T: int>` was wrongly reported as xphp.undeclared_type: the bound path set the suspect flag without the scalar exclusion the default path already had. Fold the scalar check into the shared isSuspectUndeclared() so bounds and defaults treat `int`/`string`/`self`/… the same way, and pin it with a scalar bound in the clean fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Capture the significant, hard-to-reverse design choices behind xphp as a set of public MADR-format records under docs/adr/ — monomorphization vs type erasure, the build-time transpiler model, RFC-aligned turbofish syntax, marker interfaces for instanceof, nominal/erased bound checking, the specialization depth cap, the `xphp check` gate and its collect-or-throw seam, the PHPStan-over-compiled-output layer, undeclared-type/arity validation, PHAR distribution, and the engineering quality bar. Each records the problem, options considered, the choice, and its trade-offs. Adds an index + template and links them from the docs index and CONTRIBUTING. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The record claimed an unprovable bound "slips through to a runtime TypeError" and that PHPStan closes that gap. The bound check actually passes only on a proven `true`: a `false` is reported as a definite violation, and a `null` (a type the compiler can't see) is also reported at check/compile time with a distinct "cannot prove it satisfies the bound" message. Reframe the decision as conservative rejection of the unknown case — the three-valued result exists to explain that rejection accurately, not to tolerate it — and clarify that PHPStan (ADR-0009) handles body value-flow, a separate concern from bound satisfaction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic clause on a namespace-import `use` was scanned as a type
instantiation. Depending on the import form this either mis-blamed a
double-qualified phantom template at a null line, or — for a grouped
import whose (mis)resolved member collided with a real template — drove
a specialization whose absolute name was emitted inside a `use N\{…}`
prefix group. That last case is unparseable PHP produced silently: it
passed both `check` and `compile` and only failed when the emitted file
was loaded.
Reject the clause at resolve time, before the blanket Name branch
consumes its marker. The import forms are precisely typed at the AST
(`Stmt\Use_` / `Stmt\GroupUse`, matching each member name and the group
prefix), so the reject can never touch a generic trait-use
(`Stmt\TraitUse`), which stays a supported, specialized construct. The
rejection throws `XphpParseException`, so it carries the offending
import's real line and is collected in `check` and thrown in `compile`
alike, naming the source-spelled symbol rather than the requalified
phantom. Matching is byte-exact, so two imports of the same qualified
name (one clause-less) never cross-fire.
`use function …<…>` is unaffected: its clause is left unstripped, so
php-parser reports its own syntax error before this stage.
Pinned by check-mode rejects across all five import forms (right symbol,
right line), a same-spelling non-cross-fire case, an exact-message
assertion, compile-mode throw, clean ordinary/grouped imports, and an
execute test proving generic trait-use still specializes and runs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CHANGELOG: Fixed entry for rejecting a generic clause on a `use` import (single/aliased/const/grouped), eliminating the grouped silent unparseable emit. - errors.md: broaden the xphp.parse_error row to cover parse-time xphp rejections (variance markers, malformed defaults, generic clause on a use import) reported at the offending line, not only post-strip PHP syntax errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Context PHP resolves a free-function name against `use function` imports (and a const against `use const`), independently of class imports — a class `use App\Helper;` never binds a `helper()` call. NamespaceContext lumped all three import kinds into one alias map, so it could not resolve a function or const name correctly. Partition `use function` / `use const` imports into their own maps (the class map still keeps every import, so class resolution and isImported() are unchanged), and add resolveFunctionName / resolveConstName: an unqualified name binds its own import map then the current namespace (no leading backslash added — the caller's in-set guard decides whether to fully-qualify, preserving PHP's global fallback); a qualified name's leading segment resolves via the class/namespace map; FQ and relative names bind as PHP does. This is the resolution half of re-qualifying free-function and const references in relocated generic template bodies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the FQNs of every free function and free constant declared in the compilation unit while the definitions pass walks the ASTs. Function names are keyed case-insensitively (function and namespace names are), const names with a case-insensitive namespace but a case-sensitive short name. Class methods and class constants are different node types and are not collected. This is the membership half of re-qualifying unqualified free-function/const references in relocated generic template bodies: the Specializer will fully qualify such a reference only when its resolved target is known here, so builtins and any name the unit does not define keep PHP's global fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d bodies A generic class body is relocated out of its origin namespace into XPHP\Generated\… when it specializes, so an unqualified free-function call (`helper()`) or const fetch (`FACTOR`) in it rebinds against the generated namespace — then PHP's global fallback — instead of the origin. When the origin defined that symbol, the specialized copy called a phantom that does not exist and fatalled at load/run (silently, under --no-check). The resolver now records each free-function callee's and const fetch's resolved FQN (honoring the file's `use function` / `use const` imports, relative and qualified spellings), and after the fixed-point loop every finalized specialization is swept: an unqualified reference is fully-qualified to that FQN only when the compilation unit defines the symbol. So an in-unit `helper()` becomes `\App\helper()`, a `use function Vendor\make` call becomes `\Vendor\make()`, and a relative or qualified spelling is pinned to the same target the template resolved — while builtins (`strlen`), magic constants (`true`/`false`/`null`), and any name the unit does not define keep the global fallback untouched. Generic functions/methods are unaffected: their specializations are appended into the origin namespace, not relocated. Pinned by execute fixtures that compile and RUN the output: a body mixing bare, qualified, const, builtin, and magic-constant references (asserting the computed result), and a cross-namespace `use function`/`use const` template. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ported bodies
Two gaps in the free-function/const re-qualification of relocated template
bodies, both surfacing as `Call to undefined function XPHP\Generated\…()` at
runtime behind a clean compile:
- Covariant-upcast gap-fill appends erasable members onto concrete specs AFTER
the main re-qualification sweep, so a gap-filled body that called an in-unit
free function or read an in-unit const kept the bare name and fatalled when
that upcast member was invoked. The sweep now also runs over each spec the
gap-fill touches, before the call-site rewrite. Idempotent on members that
were already fully qualified.
- Group-form imports (`use function N\{a, b}`, `use N\{function a, const B}`)
were never indexed into the function/const symbol maps — only single `use`
statements were — so a body referencing a group-imported symbol resolved to
the wrong namespace and fatalled. GroupUse is now indexed alongside Use_ in
both collector visitors, routing only its function/const members and leaving
class resolution untouched.
Both are pinned by execute fixtures confirmed to fatal without the fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Note in the runtime-semantics guide that a relocated specialization still binds the free functions and constants of its template's namespace, and add the matching changelog entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ad of dropping it A turbofish on a dynamically-named method or static call — `$o->$m::<int>()`, its nullsafe `$o?->$m::<int>()` and variable-variable `$$g::<int>()` and static-dynamic `Foo::$m::<int>()` siblings — recorded a `variableTurbofish` marker that no AST node could ever bind (those parse to a MethodCall/StaticCall or a dynamic-name FuncCall, never the `FuncCall(name: Variable)` the marker matches). The marker was silently discarded while the `::<…>` clause had already been stripped, leaving a bare dynamic call against a method that now exists only in its specialized `_T_<hash>` form — a runtime fatal behind a clean compile and check. Such a call is unmonomorphizable: the method name is a runtime value. The scanner now rejects it with a clear parse-stage diagnostic at the receiver's real line (collected by check, thrown by compile) rather than dropping the marker. The reject fires only when the variable is immediately preceded by `->`/`?->`/`::`/`$` — a standalone `$f::<…>()` (the generic-closure call shape) is untouched, so that keeps working. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PHP permits every semi-reserved keyword (`list`, `print`, …) as a method name, but a generic method whose name was a keyword could neither be declared nor statically called. The declaration scanner required a T_STRING name, so `public function list<T>` left its `<T>` clause to reach php-parser as a raw parse error; and the static call-site scanner rejected a keyword name after `::`, so `Foo::list::<int>()` parse-errored too. (The instance call `$o->list::<int>()` already worked, because PHP re-tokenizes `list` as T_STRING after `->`.) Both gates now accept a keyword whose text is a valid PHP label: the declaration gate records the generic-method marker, and a dedicated static-call branch strips the `Recv::keyword::<…>` turbofish into a `named` marker the resolver's StaticCall arm already binds. The static branch is kept separate from the name-token gate so keyword tokens never reach that gate's bare-`<`, closure-signature, or array-sugar sub-branches — `list($a, $b) = …` destructuring and keyword-named non-generic methods pass through untouched. A keyword-named generic method now declares, specializes, and runs through both call sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation-visible The dynamically-named-turbofish reject carried a defensive `$prevIdx >= 0` bounds guard annotated `@infection-ignore-all`. skipWsBack can never actually walk off the front here (index 0 is always the open tag, never whitespace, and never a reject token), so the guard is dead — but a block-scoped ignore risked masking mutants on the reject discriminator itself. Fold the bounds check into a `$tokens[$idx] ?? null` lookup and drop the annotation, so every clause of the `->`/`?->`/`::`/`$` discriminator stays exposed to mutation testing (each is killed by its own reject fixture). Behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic methods Add changelog entries and a turbofish rule: a turbofish on a dynamically-named method or static call is rejected (the specialized name can't come from a runtime value), while a generic method may be named with a PHP keyword and called through both the instance and static turbofish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic class body is relocated out of its origin namespace into
XPHP\Generated\…, so every class reference inside it is re-qualified
against the enclosing use-map. `indexUse` stored each single import there,
but `indexGroupUse` never did — so a group-imported class (`use Vendor\{Tool};`)
referenced in a relocated body fell back to the current namespace
(`\App\Tool`), compiled and linted clean, then fataled at runtime with
"Class App\Tool not found".
`indexGroupUse` now populates the class/namespace use-map for every group
member, mirroring `indexUse`, so a group-imported class re-qualifies to its
import target exactly like its single-import form. `use function` / `use const`
members continue to route additionally into their own symbol maps.
Covered by a compile-and-execute fixture (a group import and a single import
side by side in one relocated Box<int> body) pinned against the pre-fix fatal,
plus NamespaceContext unit tests asserting the group class import lands in the
class map (and honours an alias) without polluting the function/const maps.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A generic trait used with an `insteadof` / `as` adaptation block specialized
its `use`-list names (`use A<int>, B<int>`) to their `\XPHP\Generated\…` form
but left the adaptation operand names bare (`A::m insteadof B; B::m as bm;`).
The bare operands resolved to the now-removed template (`App\A`) and fataled at
class load with "Trait App\A not found" behind a clean compile and check.
The resolver visitor now, on entering each class-like body, builds a class-scoped
map of every generic trait-use list entry (keyed on resolved template FQN) and
rewrites each adaptation operand that names a generic trait to the same
specialization as its list entry — so the adapted class loads and runs. The map
is class-scoped, not per-`use`-statement, because an adaptation may reference a
trait brought in by a different `use` of the same class (`use A<int>; use B<int>
{ A::m insteadof B; }`). Operands are matched by resolved FQN, so a qualified or
aliased spelling still binds its bare list entry, and the two records dedupe to
one generated trait.
A non-generic trait operand (or one the class does not use generically) is left
exactly as written — it is a real, still-existing trait. A bare operand that
matches two different specializations of one trait (`use A<int>, A<string> {
A::m insteadof B; }`) cannot be disambiguated in an adaptation clause and draws a
clear diagnostic (collected by check, thrown by compile) instead of silently
picking one.
Covered by compile-and-execute fixtures — a cross-`use`-statement insteadof/as
pair and a mixed generic/plain block with a two-trait insteadof — both pinned
against the pre-fix class-load fatal, plus a collected/thrown ambiguity reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nable The rejection pointed users at "give the conflicting traits distinct names", but an `insteadof` / `as` clause renames methods, not traits — there is no trait-level rename to make. Reword the remedy to the real recourse (use a single specialization of the trait in an adapted class) and pin the corrected phrasing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Across the diagnostic, parser, closure-conformance, variance, registry, renderer, and static-analysis suites, ~90 `assertStringContainsString` substring checks are upgraded to exact assertions: full-message `assertSame` on diagnostic/exception text (sourced from the production message builders), whole-string `assertSame` on deterministic renderer/config output and on the byte-length-preserving closure-signature erasure, and structured field assertions (violation `->kind`, generated-FQN prefixes) where a typed accessor exists. Several tests that fished multiple substrings (plus a companion `assertStringNotContainsString`) out of one value collapse into a single exact assertion that subsumes both the positive and negative coverage. Genuinely variable haystacks — PHPStan's own output, CommandTester display chrome, and hash-bearing emitted PHP already pinned by snapshots — are left as substring/anchored checks, where an exact match would couple to third-party or non-deterministic formatting. No production code changes. Full suite green (1276), PHPStan L9 clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…grams Split the single 600-line guide into an index (intro, pipeline-overview diagrams, a stage table-of-contents, cross-cutting concerns, and the class roster) plus one page per stage under docs/guides/how-it-works/: source parsing, hierarchy/registry, method-function specialization, the fixed-point loop, variance edges, rewriting/emission, bound validation, and the validation gate. Each stage page keeps its diagram and gains prev/index/next navigation. The index keeps its path, so existing inbound links are unaffected; relative links inside the moved content were re-based for the deeper directory. Add Mermaid diagrams for the validation gate (how check and a safe compile share one gate), closure-signature checking (erase to a bare Closure, check only the statically-decidable return-position literal), and variance extends-edge emission (the real subtype links wired between specializations of a variant template). Reword the phase-labels note for the split layout: drop the stale "six narrative stages below" phrasing, complete the internal phase list (2.3, 2.4, 3.5), call out variance-edge emission as Phase 2.5 / Stage 4.5, and note the validation gate as command-level orchestration rather than a numbered phase. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…turbofish Two byte-offset edge cases were exercised only indirectly. Add direct regression tests: - A genuine multibyte identifier (`Café`, bytes >= 0x80) sitting against its `<T>` marker, with a second generic class after it. Asserts the blanked clause stays byte-exact (multibyte name bytes are the identifier, outside the span) so the later class's byte-keyed marker does not shift off its node. - A byte-keyed turbofish (`Box::<int>`) that follows a length-changing `T[]` -> `array` sugar span on the same scope. Asserts the turbofish still attaches its `<int>` argument, i.e. the stripped-source shrink does not desync markers matched in stripped space. Both pass; no source changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The conformance section stated "parameters are contravariant, the return
is covariant" but only demonstrated it with int/string -- unrelated
scalars, so neither is "wider" and the variance never shows. Add a
concrete section over a LivingThing > Animal > {Dog, Cat} hierarchy with
a target of Closure(Dog): Animal:
- a six-row accept/reject table (wider param accepted, narrower return
accepted, sibling param and wider return rejected), each verdict
matching the compiler's actual xphp.closure_conformance output;
- the intuition spelled out from the slot's point of view -- it only
ever passes a Dog and only promises an Animal back -- including why the
"I expect a Dog, not a Cat" instinct is what makes a wider Animal
handler safe rather than unsafe;
- a note tying this per-signature structural check to PHP's own method
override rules, and cross-linking declaration-site out T / in T class
variance as the same principle by a different mechanism.
Also add a See-also footer linking variance, closures-and-arrows, the
error catalogue, and the conformance fixtures. Docs only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e type A default inside a `Closure(...)` signature type (`Closure(int $x = 0): int`) was silently mis-parsed: the parameter reader never consumed the `= <expr>`, so the caller's loop re-scanned it as two phantom parameters, inflating the target's required arity. A perfectly valid returned closure literal was then false-rejected with a nonsensical "expects at least 3 parameter(s)" message. A signature type describes the callable's shape, not call-time values, so a default has no meaning there. Reject it at parse time with a clear message that names the parameter (or its 1-based position when unnamed), pointing at the `=` line. The guard sits after the name consumption, so it also supersedes the "variadic must be last" path for a defaulted variadic. `compile` throws; `check` collects it as a parse-error diagnostic at the real line. Behavioral accept/reject pairs pin the new logic in both modes (the parser reader carries an infection-ignore, so MSI there is vacuous): named, unnamed, and variadic defaults reject with the exact message; single / multi-param / variadic default-free signatures still parse, and a nested closure signature stays accepted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…a helper The Phase 2 loop that specializes every instantiation to a fixed point (and closes the set under the covariant-upcast requirement) was inline in compile(). Extract it into a private specializeToFixedPoint() so a second caller can reuse it. The helper takes a `resilient` flag: false reproduces compile's fail-fast behavior exactly (undefined template / exceeded depth throw), true (for a forthcoming validate-only caller) skips an unspecializable instantiation and stops at the depth cap instead of throwing. Pure refactor — compile's behavior is unchanged (the loop body is moved verbatim; the stateless closer is reconstructed for the later gap-fill phase). Full suite and PHPStan L9 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pile A closure signature that names an enclosing type parameter (`Closure(T $x)`) is gradually accepted while T is abstract and only becomes a provable mismatch once T grounds (e.g. `Registry<string>` ⇒ `Closure(string $x)`). compile catches this per specialization (Phase 2.4); check did not — it never specialized. So `xphp check --no-phpstan` silently passed code that compile rejects, and the default PHPStan-backed check reached the same reject through the compile-driven pass and surfaced it as a raw exception box instead of a diagnostic. check now specializes to a fixed point in resilient mode — reusing the extracted Phase-2 loop, so it discovers transitively-instantiated generics (a `Closure(T)` factory reached only through another generic), not just source-visible ones — then runs the type-relation half of conformance over every specialization in collector mode. Arity / by-reference mismatches are grounding-independent and already collected by the abstract pre-loop, so the grounded pass skips them (new checkTypesOnly / groundedTypesOnly seam) to avoid a duplicate report at the synthetic specialized location. Both check modes now report the same clean `xphp.closure_conformance` diagnostic compile does, and the exception box is gone (the gate short-circuits before the PHPStan pass once check has an error). Resilient mode skips an undefined template (already reported), an arity-mismatched instantiation (already reported; would otherwise ValueError in array_combine), and a specialization that throws, and stops at the depth cap instead of aborting — a conformance pass over a partial set can only miss a provable violation, never invent one. Grounded reject / accept, the transitive case, single-report-of-structural, and a non-generic structural mismatch are pinned; 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ure positions A `Closure(...)` signature type in a position xphp doesn't support used to fail only via a misleading nikic parse error (`unexpected '<'`) that never mentioned closures. Each unsupported position now reports a clear, closure-specific error at the real source line — still always before any emission, so the cardinal safety rule holds: - Generic bound (`class C<T : Closure(int): int>`) — rejected at the bound reader, a declaration-header seam that is never the speculative `<`-comparison path, so the reject is safe. A bare `\Closure` bound stays valid. - Untyped signature parameter (`Closure($x): int`) — the pre-filter now lets a `$var` first-inner through to the position gate, which only a genuine type slot satisfies; a real `Closure($x)` expression is never followed by a `$var` / return slot, so it is untouched. - Generic type argument (`Box<Closure(int): int>`), the same nested in an F-bound, and the `Name<Args>[]` combination — these share the speculative generic-arg reader, so intercepting them there would false-reject valid code like `$a < Closure(5)`. Instead the already-failed nikic parse is enriched: only a parse that has ALREADY failed is inspected, so valid code (which parses cleanly) can never be reached or rejected — safe by construction. Pinned by reject tests in both modes (compile throws, check collects at the real line) plus no-misfire tests: a `<` comparison against a user function named `Closure` stays valid, a bare `\Closure` bound/argument is untouched, and an unrelated syntax error keeps nikic's original message. 100% MSI on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…imitations List the positions where a Closure(...) signature type is not yet accepted — generic type argument, generic bound, the nested-in-a-bound and Name<Args>[] combinations, and an untyped signature parameter — each noted as a clear compile error today and possible future scope, with the bare \Closure workaround. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… rejects Record in the changelog that grounded closure conformance is checked identically by compile and check, and that unsupported signature positions (generic argument/bound, Name<Args>[], defaulted/untyped parameters) are clear compile errors. Extend the xphp.parse_error catalogue entry to list the new closure parse-time rejections. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion Add Closure(...) signature types everywhere the public docs enumerate features: the roadmap (Shipped section + Discovery items for the unsupported positions), the comparison feature grid, the syntax quick-reference card, a caveats section for the unsupported positions, and the errors catalogue (verbatim parse-reject texts plus quick-index rows, including the missing closure-conformance row). Restructure the roadmap to two sections — Shipped and Discovery — moving the former Next items (stream-wrapper live transpilation, source maps) under Discovery → Ecosystem, and align the docs-index and getting-started cross-references with the new structure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…neric enricher PhpParser's getAttributes() returns array<string, mixed>, and the closure-in-generic error enricher used startFilePos as an int after only an isset() check, leaving the scan cursor and substr offset mixed at PHPStan's eyes. Narrow with is_int() instead — behaviorally identical (nikic always sets the attribute as an int when file positions are on), but the offsets are now proven integers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(!) Closure signature types, out/in variance markers, and a validating compile gate
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.